本文旨在建立 Raspberry Pi 5 的自動化備份腳本,可將資料備份至遠端的 NAS (網路附加儲存)。其設計包含多個模組化腳本,並整合了特定的安全措施。
腳本的安全性設計
此備份腳本在設計上整合了幾項安全考量:
專用使用者 腳本會建立一個名為
backup-runner的系統使用者,其 Shell 設定為/usr/sbin/nologin,限制了此帳號的互動式登入能力。備份任務均由此使用者身份執行。sudo權限限制 透過在/etc/sudoers.d/目錄下建立設定檔,腳本為backup-runner使用者提供了執行特定指令的權限,而非完整的root權限。這遵循了最小權限原則。憑證檔案隔離 NAS 的登入憑證被儲存在
/etc/samba/nas-credentials檔案中,權限設定為600,僅供root使用者讀取。主腳本透過此檔案間接取得憑證。SMB 傳輸加密 掛載遠端分享時,腳本優先嘗試 SMB 3.x 協議並啟用
seal選項,該選項用於加密傳輸中的資料封包。可選的檔案加密 腳本支援使用 age 工具進行檔案加密。若使用者提供
age公鑰,備份檔在寫入 NAS 前會先經過加密處理。
備份工作流程
備份流程由主腳本 master-backup.sh 進行協調,它會呼叫其他輔助腳本來完成掛載、打包、清理等各項子任務。以執行應用程式備份 (master-backup.sh app) 為例,其標準執行流程如下:
sequenceDiagram
participant Cron as Cron/User
participant Master as master-backup.sh
participant Mount as backup-mount
participant AppDumps as backup-app-dumps
participant Pack as backup-pack
participant NAS as NAS/CIFS Share
participant Notify as Notification Service
Cron->>Master: 執行 `master-backup.sh app`
Master->>Mount: (sudo) 請求掛載 NAS
Mount-->>NAS: 嘗試以 SMB 3.1.1 + seal 掛載
NAS-->>Mount: 掛載操作完成
Mount-->>Master: 回報掛載結果
Master->>AppDumps: (sudo) 請求備份應用程式資料
AppDumps->>AppDumps: 執行 pg_dumpall, mysqldump 等資料庫匯出指令
AppDumps->>AppDumps: 將匯出檔案儲存至 /var/tmp/backup-staging/ 的臨時目錄
AppDumps->>Pack: (sudo) 使用 dir 模式請求打包臨時目錄
Pack->>Pack: 使用 `tar` 指令壓縮臨時目錄
Pack->>Pack: (若已設定) 使用 `age` 加密 .tar.gz 檔案
Pack->>NAS: 將最終備份檔 (.tar.gz 或 .tar.gz.age) 移動至 NAS
NAS-->>Pack: 寫入操作完成
Pack-->>AppDumps: 回傳最終檔案路徑
AppDumps-->>Master: 回傳備份結果
Master->>Master: 呼叫 cleanup_old_backups 函數
Master->>NAS: 根據 APP_RETENTION 設定清理舊備份檔
Master->>Notify: (若已設定) 發送執行結果通知
Notify-->>Notify: 經由 Telegram API 或 ssmtp/msmtp 發送訊息
Master->>Mount: (經由 trap EXIT) 確保卸載 NAS
Mount-->>NAS: 執行 umount
Master-->>Cron: 執行結束
主要功能模組
1. 掛載邏輯 (backup-mount)
此腳本處理掛載遠端 CIFS/SMB 分享的邏輯。它會遍歷 SMB_VERSION_LIST 變數中指定的協議版本(預設 3.1.1,3.0,2.1,2.0),並優先嘗試加密掛載。若 REQUIRE_SMB_ENCRYPTION 變數設為 false,在加密掛載失敗後,會繼續嘗試非加密的掛載方式。
2. 應用程式資料匯出 (backup-app-dumps)
對於資料庫類型的應用,直接複製檔案可能導致資料不一致。此腳本的設計是為了處理這種情況。它會檢查系統中是否存在如 Gitea、PostgreSQL、MariaDB/MySQL 等服務的指令,若存在,則呼叫對應的備份工具(如 gitea dump, pg_dumpall, mysqldump)來產生一致性的資料匯出檔。所有匯出的檔案會被放置在一個臨時目錄中,以待後續打包。
3. 核心封裝程序 (backup-pack)
此腳本是執行動檔案打包和壓縮的核心。它主要執行以下操作:
使用
tar進行封裝,並包含--numeric-owner,--acls,--xattrs等參數以保留檔案的元資料。使用
gzip進行壓縮。根據
AGE_RECIPIENT環境變數是否存在,決定是否執行age加密。將最終產生的檔案透過
mv指令移動到指定的備份目的地。
4. 自動化管理與通知
備份保留策略 (
cleanup_old_backups)master-backup.sh腳本內建了清理舊備份的函數。每次備份成功後,該函數會根據APP_RETENTION和SYSTEM_RETENTION變數的值,保留指定數量的最新備份,並刪除較舊的檔案。執行狀態通知 使用者可選填
/etc/backup-notify.env檔案中的變數,來啟用 Telegram 或 Email 通知。腳本會在任務執行成功或失敗時發送通知訊息。
使用方法
執行安裝腳本 透過
sudo bash install-backup-suite.sh執行安裝程序。腳本提供互動式介面,引導使用者輸入 NAS 位址、憑證、掛載點等設定。手動測試:安裝後,可透過以下指令進行功能驗證:
# 測試應用程式備份流程 sudo -u backup-runner /usr/local/bin/master-backup.sh app # 測試通知發送功能 sudo -u backup-runner /usr/local/sbin/backup-notify-test --both設定排程任務:可使用
crontab為backup-runner使用者設定自動化排程。安裝腳本可協助產生一個範例crontab項目。
一鍵安裝腳本
#!/usr/bin/env bash
set -euo pipefail
# ======== 可用環境變數覆蓋 ========
BACKUP_USER="${BACKUP_USER:-backup-runner}"
NAS_SERVER_IP="${NAS_SERVER_IP:-10.0.0.5}"
NAS_SHARE_NAME="${NAS_SHARE_NAME:-SharedSSD}" # 只放 share 名,不要包含子資料夾
NAS_SUBDIR="${NAS_SUBDIR:-}" # share 底下的子資料夾(可留空)
NAS_DOMAIN="${NAS_DOMAIN:-WORKGROUP}" # 工作群組/網域(可留空)
NAS_USERNAME="${NAS_USERNAME:-nasuser}"
NAS_PASSWORD="${NAS_PASSWORD:-naspass}"
LOCAL_MOUNT_POINT="${LOCAL_MOUNT_POINT:-/mnt/nas_backup}"
APP_BACKUP_DIR="${APP_BACKUP_DIR:-${LOCAL_MOUNT_POINT}/app_backups}"
SYSTEM_BACKUP_DIR="${SYSTEM_BACKUP_DIR:-${LOCAL_MOUNT_POINT}/system_backups}"
SYSTEM_DIRS_TO_BACKUP="${SYSTEM_DIRS_TO_BACKUP:-/etc /home /root /srv /var/www /usr/local/bin}"
APP_RETENTION="${APP_RETENTION:-24}"
SYSTEM_RETENTION="${SYSTEM_RETENTION:-7}"
AGE_RECIPIENT="${AGE_RECIPIENT:-}" # 例如 age1xxxx...;留空=不加密
REQUIRE_SMB_ENCRYPTION="${REQUIRE_SMB_ENCRYPTION:-true}" # 老 NAS 可設為 false
SMB_VERSION_LIST="${SMB_VERSION_LIST:-3.1.1,3.0,2.1,2.0}" # 需要 SMB1 才加 1.0(不建議)
BACKUP_TMP_PARENT="${BACKUP_TMP_PARENT:-/var/tmp/backup-staging}"
# ======== 互動式提示(非必要) ========
ask() {
local prompt default var
prompt="$1"; default="$2"; var="$3"
read -rp "$prompt [$default]: " __ans || true
eval "$var=\"${__ans:-$default}\""
}
if [[ -t 0 ]]; then
echo "== 互動式基本設定(Enter 採用預設) =="
ask "NAS IP" "$NAS_SERVER_IP" NAS_SERVER_IP
ask "NAS 共享名稱(只填 share 名稱)" "$NAS_SHARE_NAME" NAS_SHARE_NAME
ask "NAS 子資料夾(可空白,用於 prefixpath)" "$NAS_SUBDIR" NAS_SUBDIR
ask "NAS 工作群組/網域(可空白)" "$NAS_DOMAIN" NAS_DOMAIN
ask "NAS 帳號" "$NAS_USERNAME" NAS_USERNAME
ask "NAS 密碼(寫入 /etc/samba/nas-credentials)" "$NAS_PASSWORD" NAS_PASSWORD
ask "掛載點" "$LOCAL_MOUNT_POINT" LOCAL_MOUNT_POINT
ask "APP 備份路徑" "$APP_BACKUP_DIR" APP_BACKUP_DIR
ask "SYSTEM 備份路徑" "$SYSTEM_BACKUP_DIR" SYSTEM_BACKUP_DIR
ask "系統備份目錄(空白分隔)" "$SYSTEM_DIRS_TO_BACKUP" SYSTEM_DIRS_TO_BACKUP
ask "保留 APP 份數" "$APP_RETENTION" APP_RETENTION
ask "保留 SYSTEM 份數" "$SYSTEM_RETENTION" SYSTEM_RETENTION
ask "age 公鑰(空白=不加密)" "$AGE_RECIPIENT" AGE_RECIPIENT
ask "是否強制 SMB 加密(true/false)" "$REQUIRE_SMB_ENCRYPTION" REQUIRE_SMB_ENCRYPTION
ask "SMB 版本嘗試序列(逗號分隔)" "$SMB_VERSION_LIST" SMB_VERSION_LIST
ask "暫存根目錄(放臨時壓縮檔)" "$BACKUP_TMP_PARENT" BACKUP_TMP_PARENT
fi
# ======== 前置:root / 套件 ========
if [[ $EUID -ne 0 ]]; then
echo "請用 root 執行:sudo bash $0"; exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y cifs-utils age curl
# ======== 建使用者(含同名群組) ========
if ! id -u "$BACKUP_USER" >/dev/null 2>&1; then
useradd --system --user-group --create-home --home-dir "/var/lib/${BACKUP_USER}" \
--shell /usr/sbin/nologin "$BACKUP_USER"
fi
# ======== 目錄 ========
install -d -m 700 "$LOCAL_MOUNT_POINT"
install -d -m 700 "$(dirname "$APP_BACKUP_DIR")" "$(dirname "$SYSTEM_BACKUP_DIR")" || true
install -d -m 700 "$BACKUP_TMP_PARENT"
# ======== 設定檔 ========
cat >/etc/backup-secure.conf <<EOF
# 安全備份設定
NAS_SERVER_IP="$NAS_SERVER_IP"
NAS_SHARE_NAME="$NAS_SHARE_NAME"
NAS_SUBDIR="$NAS_SUBDIR"
NAS_DOMAIN="$NAS_DOMAIN"
NAS_CREDENTIALS_FILE="/etc/samba/nas-credentials"
LOCAL_MOUNT_POINT="$LOCAL_MOUNT_POINT"
APP_BACKUP_DIR="$APP_BACKUP_DIR"
SYSTEM_BACKUP_DIR="$SYSTEM_BACKUP_DIR"
SYSTEM_DIRS_TO_BACKUP="$SYSTEM_DIRS_TO_BACKUP"
AGE_RECIPIENT="$AGE_RECIPIENT"
REQUIRE_SMB_ENCRYPTION="$REQUIRE_SMB_ENCRYPTION"
SMB_VERSION_LIST="$SMB_VERSION_LIST"
BACKUP_TMP_PARENT="$BACKUP_TMP_PARENT"
EOF
chgrp "$BACKUP_USER" /etc/backup-secure.conf
chmod 640 /etc/backup-secure.conf
# NAS 憑證
cat >/etc/samba/nas-credentials <<EOF
username=$NAS_USERNAME
password=$NAS_PASSWORD
EOF
chmod 600 /etc/samba/nas-credentials
# ======== 包裝指令 ========
cat >/usr/local/sbin/backup-mount <<"EOF"
#!/bin/bash
set -euo pipefail
umask 077
# 載入設定
. /etc/backup-secure.conf
REQUIRE_SMB_ENCRYPTION=${REQUIRE_SMB_ENCRYPTION:-true}
SMB_VERSION_LIST=${SMB_VERSION_LIST:-3.1.1,3.0,2.1}
NAS_SUBDIR=${NAS_SUBDIR:-}
NAS_DOMAIN=${NAS_DOMAIN:-}
# 已掛載就離開
if mountpoint -q "$LOCAL_MOUNT_POINT"; then
exit 0
fi
install -d -m 700 "$LOCAL_MOUNT_POINT"
mount_try() {
local v="$1" enc="$2"
local opts="credentials=${NAS_CREDENTIALS_FILE},vers=${v},nosuid,nodev,noexec,dir_mode=0700,file_mode=0600"
[ -n "$NAS_DOMAIN" ] && opts="${opts},domain=${NAS_DOMAIN}"
[ -n "$NAS_SUBDIR" ] && opts="${opts},prefixpath=${NAS_SUBDIR}"
# 只有 SMB3 支援 seal
if [ "$enc" = "on" ] && [ "${v%%.*}" = "3" ]; then
opts="${opts},seal"
fi
/usr/sbin/mount.cifs "//${NAS_SERVER_IP}/${NAS_SHARE_NAME}" "$LOCAL_MOUNT_POINT" -o "$opts"
}
# 先試加密
if [ "$REQUIRE_SMB_ENCRYPTION" = "true" ]; then
for v in ${SMB_VERSION_LIST//,/ }; do
if mount_try "$v" on 2>/dev/null; then
echo "Mounted: vers=$v, seal"
exit 0
fi
done
echo "ERROR: cannot mount with encryption (seal). Enable SMB encryption on NAS or set REQUIRE_SMB_ENCRYPTION=false."
exit 1
fi
# 再試未加密
for v in ${SMB_VERSION_LIST//,/ }; do
if mount_try "$v" off 2>/dev/null; then
echo "Mounted: vers=$v (no encryption)"
exit 0
fi
done
echo "ERROR: all mount attempts failed. Check credentials/share/version list."
exit 1
EOF
chmod 700 /usr/local/sbin/backup-mount
cat >/usr/local/sbin/backup-umount <<"EOF"
#!/bin/bash
set -euo pipefail
. /etc/backup-secure.conf
if mountpoint -q "$LOCAL_MOUNT_POINT"; then
exec /usr/bin/umount "$LOCAL_MOUNT_POINT"
fi
EOF
chmod 700 /usr/local/sbin/backup-umount
cat >/usr/local/sbin/backup-pack <<"EOF"
#!/bin/bash
# backup-pack: 以固定參數打包並寫入 NAS
# 用法:backup-pack app|system|dir SRC
set -euo pipefail
umask 077
. /etc/backup-secure.conf
MODE="${1:-}"
case "$MODE" in
app|system|dir) : ;;
*) echo "usage: $0 {app|system|dir SRC}" >&2; exit 2 ;;
esac
ts="$(date +%Y%m%d_%H%M%S)"
parent="${BACKUP_TMP_PARENT:-/var/tmp/backup-staging}"
mkdir -p "$parent"; chmod 700 "$parent"
tmpdir="$(mktemp -d -p "$parent" -t bkp.XXXXXX)"
trap 'rm -rf "$tmpdir"' EXIT
# 預先宣告,避免 set -u 下未綁定錯誤
dest_dir=""; name=""
if [[ "$MODE" == "system" ]]; then
dest_dir="$SYSTEM_BACKUP_DIR"; name="system_backup_${ts}"
elif [[ "$MODE" == "app" ]]; then
dest_dir="$APP_BACKUP_DIR"; name="app_backup_${ts}"
else # dir 模式:由外部傳入 SRC,目的地仍為 APP_BACKUP_DIR
SRC="${2:-}"; [[ -n "$SRC" && -d "$SRC" ]] || { echo "dir 模式需要現有目錄" >&2; exit 2; }
dest_dir="$APP_BACKUP_DIR"; name="app_backup_${ts}"
fi
tar_args=( -cpf - --numeric-owner --acls --xattrs --xattrs-include='*' )
if [[ "$MODE" == "app" ]]; then
# 僅在明確使用 app 模式時才參考 APP_DIRS_TO_BACKUP
if [[ -z "${APP_DIRS_TO_BACKUP:-}" ]]; then
echo "APP_DIRS_TO_BACKUP 未設定" >&2; exit 2
fi
for d in $APP_DIRS_TO_BACKUP; do tar_args+=( -C / "${d#/}" ); done
elif [[ "$MODE" == "system" ]]; then
tar_args+=( --one-file-system \
--exclude=/proc --exclude=/sys --exclude=/dev/pts \
--exclude=/run --exclude="$LOCAL_MOUNT_POINT" \
--exclude=/tmp --exclude=/var/tmp --exclude=/lost+found \
/ )
else
tar_args=( -cpf - --numeric-owner --acls --xattrs --xattrs-include='*' -C "$SRC" . )
fi
plain="${tmpdir}/${name}.tar.gz"
enc="${plain}.age"
# 允許 live 系統中文件在打包過程改變(tar rc=1),並抑制該警告訊息
set +o pipefail
tar --warning=no-file-changed "${tar_args[@]}" | gzip -1n > "$plain"
tar_rc=${PIPESTATUS[0]}
set -o pipefail
if [[ $tar_rc -gt 1 ]]; then
echo "tar failed (rc=$tar_rc)" >&2
exit $tar_rc
fi
if [[ -n "${AGE_RECIPIENT:-}" ]]; then
age -r "$AGE_RECIPIENT" -o "$enc" "$plain"
rm -f "$plain"
final="${dest_dir}/${name}.tar.gz.age"; src="$enc"
else
final="${dest_dir}/${name}.tar.gz"; src="$plain"
fi
install -d -m 700 "$dest_dir"
mv -f "$src" "$final"
sync -f "$(dirname "$final")" 2>/dev/null || true
echo "$final"
EOF
chmod 700 /usr/local/sbin/backup-pack
cat >/usr/local/sbin/backup-app-dumps <<"EOF"
#!/bin/bash
set -euo pipefail
umask 077
. /etc/backup-secure.conf
parent="${BACKUP_TMP_PARENT:-/var/tmp/backup-staging}"
mkdir -p "$parent"; chmod 700 "$parent"
tmpdir="$(mktemp -d -p "$parent" -t appdump.XXXXXX)"
trap 'rm -rf "$tmpdir"' EXIT
echo " - Gitea(若存在)"
if command -v /usr/local/bin/gitea >/dev/null 2>&1 && id -u git >/dev/null 2>&1; then
install -d -m 700 "$tmpdir/gitea_tmp"
sudo -u git /usr/local/bin/gitea dump -c /etc/gitea/app.ini --tempdir "$tmpdir/gitea_tmp" -f "$tmpdir/gitea_backup.zip" || true
fi
echo " - PostgreSQL(若存在)"
if command -v pg_dumpall >/dev/null 2>&1 && id -u postgres >/dev/null 2>&1; then
sudo -u postgres /usr/bin/pg_dumpall | gzip > "$tmpdir/postgresql_all.sql.gz" || true
fi
echo " - MariaDB/MySQL(若存在)"
if command -v mysqldump >/dev/null 2>&1; then
/usr/bin/mysqldump --all-databases | gzip > "$tmpdir/mariadb_all.sql.gz" || true
fi
exec /usr/local/sbin/backup-pack dir "$tmpdir"
EOF
chmod 700 /usr/local/sbin/backup-app-dumps
# ======== sudoers(精準白名單) ========
SUDOERS_FILE="/etc/sudoers.d/${BACKUP_USER}-backup"
cat >"$SUDOERS_FILE" <<EOF
Defaults:${BACKUP_USER} secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
User_Alias BACKUPU = ${BACKUP_USER}
Cmnd_Alias BKP_CMDS = /usr/local/sbin/backup-mount, /usr/local/sbin/backup-umount, \
/usr/local/sbin/backup-pack app, /usr/local/sbin/backup-pack system, \
/usr/local/sbin/backup-pack dir *, /usr/local/sbin/backup-app-dumps
BACKUPU ALL=(root) NOPASSWD: BKP_CMDS
EOF
chmod 440 "$SUDOERS_FILE"
visudo -cf "$SUDOERS_FILE" >/dev/null || { echo "sudoers 驗證失敗"; exit 1; }
# ======== 強化版主腳本 ========
cat >/usr/local/bin/master-backup.sh <<"EOF"
#!/bin/bash
set -euo pipefail
[ -f /etc/backup-secure.conf ] && . /etc/backup-secure.conf
[ -f /etc/backup-notify.env ] && . /etc/backup-notify.env
HOSTNAME_TAG="$(hostname)"
APP_RETENTION="${APP_RETENTION:-24}"
SYSTEM_RETENTION="${SYSTEM_RETENTION:-7}"
TELEGRAM_TOKEN="${TELEGRAM_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
EMAIL_RECIPIENT="${EMAIL_RECIPIENT:-}"
NOTIFY_ON_SUCCESS="${NOTIFY_ON_SUCCESS:-true}"
send_notification() {
local status_emoji="$1" title="$2" message="$3"
local full_message="${status_emoji} [${HOSTNAME_TAG}] ${title}\n\n${message}"
if command -v curl >/dev/null 2>&1 && [[ -n "$TELEGRAM_TOKEN" && -n "$TELEGRAM_CHAT_ID" ]]; then
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" -d text="${full_message}" >/dev/null || true
fi
if command -v ssmtp >/dev/null 2>&1; then
if [[ -n "$EMAIL_RECIPIENT" ]]; then
echo -e "Subject: ${status_emoji} [${HOSTNAME_TAG}] ${title}\n\n${message}" | ssmtp "$EMAIL_RECIPIENT" || true
fi
elif command -v msmtp >/dev/null 2>&1; then
if [[ -n "$EMAIL_RECIPIENT" ]]; then
printf "To: %s\nSubject: %s\n\n%s\n" "$EMAIL_RECIPIENT" "通知:${title}" "${full_message}" | msmtp -t || true
fi
fi
}
mount_nas() { echo "掛載 NAS..."; sudo /usr/local/sbin/backup-mount; }
unmount_nas() { echo "卸載 NAS..."; sudo /usr/local/sbin/backup-umount; }
backup_applications() {
echo "正在備份應用程式..."
local out
out="$(sudo /usr/local/sbin/backup-app-dumps)"
echo "APP 備份完成: $out"
}
backup_system() {
echo "正在備份系統..."
local out
out="$(sudo /usr/local/sbin/backup-pack system)"
echo "SYSTEM 備份完成: $out"
}
cleanup_old_backups() {
if [[ -d "${APP_BACKUP_DIR:-}" ]]; then
echo "清理舊的應用程式備份 (保留 ${APP_RETENTION} 份)..."
find "$APP_BACKUP_DIR" -maxdepth 1 -type f -name 'app_backup_*.tar.gz*' -printf '%T@ %p\0' \
| sort -z -nr | tail -z -n +$((APP_RETENTION + 1)) \
| cut -z -d ' ' -f2- | xargs -0r rm -f --
fi
if [[ -d "${SYSTEM_BACKUP_DIR:-}" ]]; then
echo "清理舊的系統備份 (保留 ${SYSTEM_RETENTION} 份)..."
find "$SYSTEM_BACKUP_DIR" -maxdepth 1 -type f -name 'system_backup_*.tar.gz*' -printf '%T@ %p\0' \
| sort -z -nr | tail -z -n +$((SYSTEM_RETENTION + 1)) \
| cut -z -d ' ' -f2- | xargs -0r rm -f --
fi
}
main() {
local BACKUP_TYPE="${1:-}"
[[ -n "$BACKUP_TYPE" ]] || { echo "用法:$0 {app|system}" >&2; exit 2; }
local START_TIME; START_TIME="$(date +%s)"
handle_failure() {
unmount_nas
send_notification "❌" "備份失敗: ${BACKUP_TYPE}" "腳本執行過程中發生錯誤或被中斷。"
exit 1
}
trap 'handle_failure' ERR INT TERM
trap 'unmount_nas' EXIT
mount_nas
case "$BACKUP_TYPE" in
app) backup_applications ;;
system) backup_system ;;
*) echo "未知的備份模式 '${BACKUP_TYPE}'。" >&2; exit 1 ;;
esac
cleanup_old_backups
local END_TIME; END_TIME="$(date +%s)"
local DURATION=$((END_TIME - START_TIME))
if [[ "${NOTIFY_ON_SUCCESS}" == "true" ]]; then
send_notification "✅" "備份成功: ${BACKUP_TYPE}" "備份任務已成功完成。\n耗時: ${DURATION} 秒。"
fi
}
main "$@"
EOF
chown root:"$BACKUP_USER" /usr/local/bin/master-backup.sh
chmod 750 /usr/local/bin/master-backup.sh
# ======== 通知 env(空白模板) ========
if [[ ! -f /etc/backup-notify.env ]]; then
cat >/etc/backup-notify.env <<'EOF'
# 這個檔案若存在會被 master-backup.sh 載入
# TELEGRAM_TOKEN="your_bot_token"
# TELEGRAM_CHAT_ID="your_chat_id"
# EMAIL_RECIPIENT="you@example.com"
# NOTIFY_ON_SUCCESS=true
EOF
fi
chgrp "$BACKUP_USER" /etc/backup-notify.env
chmod 640 /etc/backup-notify.env
# ======== 通知測試工具 ========
cat >/usr/local/sbin/backup-notify-test <<"EOF"
#!/bin/bash
set -euo pipefail
notify_env="/etc/backup-notify.env"
[[ -r "$notify_env" ]] && . "$notify_env"
TELEGRAM_TOKEN="${TELEGRAM_TOKEN:-}"
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
EMAIL_RECIPIENT="${EMAIL_RECIPIENT:-}"
HOSTNAME_TAG="$(hostname)"
send_t=false; send_e=false
msg="這是一則測試通知(主機:${HOSTNAME_TAG})於 $(date -Is)"
usage(){ echo "用法:$0 [--telegram] [--email] [--both] [--message <文字>]"; exit 2; }
while [[ $# -gt 0 ]]; do
case "$1" in
--telegram) send_t=true ;;
--email) send_e=true ;;
--both) send_t=true; send_e=true ;;
--message) shift; [[ $# -gt 0 ]] || usage; msg="$1" ;;
-h|--help) usage ;;
*) echo "未知參數:$1"; usage ;;
esac; shift
done
if ! $send_t && ! $send_e; then send_t=true; send_e=true; fi
rc=0
if $send_t; then
if ! command -v curl >/dev/null 2>&1; then echo "❌ 缺少 curl"; rc=1
elif [[ -z "$TELEGRAM_TOKEN" || -z "$TELEGRAM_CHAT_ID" ]]; then echo "❌ TELEGRAM_TOKEN/CHAT_ID 未設定"; rc=1
else curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" -d chat_id="${TELEGRAM_CHAT_ID}" -d text="${msg}" >/dev/null && echo "✅ Telegram 已送出" || { echo "❌ Telegram 送出失敗"; rc=1; }
fi
fi
if $send_e; then
if [[ -n "$EMAIL_RECIPIENT" ]]; then
if command -v ssmtp >/dev/null 2>&1; then printf "Subject: 測試通知(%s)\n\n%s\n" "$HOSTNAME_TAG" "$msg" | ssmtp "$EMAIL_RECIPIENT" && echo "✅ Email 已透過 sSMTP 送出" || { echo "❌ Email(sSMTP)失敗"; rc=1; }
elif command -v msmtp >/dev/null 2>&1; then printf "To: %s\nSubject: 測試通知(%s)\n\n%s\n" "$EMAIL_RECIPIENT" "$HOSTNAME_TAG" "$msg" | msmtp -t && echo "✅ Email 已透過 msmtp 送出" || { echo "❌ Email(msmtp)失敗"; rc=1; }
else echo "❌ 找不到 ssmtp 或 msmtp"; rc=1; fi
else echo "❌ EMAIL_RECIPIENT 未設定"; rc=1; fi
fi
exit $rc
EOF
chown root:"$BACKUP_USER" /usr/local/sbin/backup-notify-test
chmod 750 /usr/local/sbin/backup-notify-test
# ======== (可選)建立 cron ========
if [[ -t 0 ]]; then
read -rp "要建立 cron 範例嗎?(y/N) " YN || true
if [[ "${YN,,}" == "y" ]]; then
tmpcron="$(mktemp)"
crontab -u "$BACKUP_USER" -l 2>/dev/null | sed '/master-backup.sh/d' >"$tmpcron" || true
{
echo "## backup suite"
echo "0 3 * * * /usr/local/bin/master-backup.sh app >>/var/log/master-backup.log 2>&1"
echo "30 4 * * 0 /usr/local/bin/master-backup.sh system >>/var/log/master-backup.log 2>&1"
} >>"$tmpcron"
crontab -u "$BACKUP_USER" "$tmpcron"
rm -f "$tmpcron"
fi
fi
echo
echo "=== 完成!檢查 sudo 權限 ==="
sudo -l -U "$BACKUP_USER" || true
echo "試跑:"
echo " sudo -u $BACKUP_USER /usr/local/bin/master-backup.sh app"
echo " sudo -u $BACKUP_USER /usr/local/bin/master-backup.sh system"
echo " sudo -u $BACKUP_USER /usr/local/sbin/backup-notify-test --both --message 'Hello from $(hostname)'"